本日程式碼:repo tag day-02
一顆 ERC-20 穩定幣,本質上是一份住在合約裡的帳本。你「持有 100 USDC」的意思,是 USDC 合約的儲存空間裡有一行寫著「你的地址 → 100000000」(USDC 與 USDT 都是小數後 6 位)。錢不在你的錢包裡,錢包只負責用私鑰簽署「請改帳本」的請求。這也是為什麼發行商有辦法凍結或銷毀你的餘額,因為帳本就是他們的合約,他們當然改得動(但當然不會隨便更改啦...)。
mapping(address => uint256) balanceOf; // 誰有多少錢
mapping(address => mapping(address => uint256)) allowance; // 誰允許誰替他花多少
要在這份帳本上搬錢,只有兩種方法:
transfer(to, amount):你自己簽一筆交易,合約檢查你的餘額夠不夠,然後直接改帳本。這是所謂「push」:錢從你手上主動推出去。approve 加 transferFrom:你先呼叫 approve(spender, amount),然後登記「我允許 spender 替我花最多 amount」的這個額度就叫 allowance。之後 spender 可以在額度內呼叫 transferFrom(payer, to, amount) 替你搬錢,每搬一次額度就扣一次。這是所謂「pull」:錢是被對方拉走的。push 很好懂,那為什麼需要 pull?因為合約沒有私鑰,不能去動你的餘額,可是結算、託管、兌換等等的操作都需要「合約收下你的錢,然後在同一筆交易裡做點什麼」。如果只有 transfer,你得先把錢推給合約、再另外通知它這筆錢是誰的、要幹嘛...不僅沒有原子性,而且也會提升整個交易的不穩定性。有了 allowance,合約可以在一筆交易裡先 transferFrom 把錢拉進來、接著執行邏輯,要嘛全部成功、要嘛全部 revert。結算合約要替客戶搬錢,用這種方法最實際。
但使用 pull 的代價是使用者要多發一筆 approve 交易、多付一次 gas,體驗不好,這件事有別的解法,之後會討論。現在只要記住:transfer 改 balanceOf,approve 改 allowance,transferFrom 兩個都改。接下來讀到的每一個特殊設計,都是針對這兩個 mapping 的某個操作。
昨天提到的藍圖:「API 收單、intent 落地、job 進 queue、relayer 組好交易,最後由結算合約呼叫 IERC20(token).transferFrom(payer, to, amount) 把錢送出去」在 USDC 會成功,但換成 USDT 的話應該會 revert,而且 revert 得莫名其妙:餘額足夠、合約沒暫停、地址沒問題,錯誤訊息一片空白。
也許你決定把呼叫改成低階的 call、並不去解碼回傳值,然後 USDT 就能成功支付了!但一段時間後,其他有些 token 出現新問題:交易成功上鏈、gas 也燒了、intent 在後端被推進到 settled,但收款人餘額一毛都沒增加。
會發生這些問題其實就是因為「ERC-20 只是一份規格,他沒有實作上的強制力」(就像每家銀行都說自己支援轉帳,但有的轉完不給你收據、有的偷偷扣手續費、有的轉帳失敗也不通知你,你要自己去查餘額才知道)。
USDT 是市值最大的穩定幣,以太坊主網上那份 TetherToken 合約在 2017 年 11 月部署,此後沒有升級過。它有四個對支付系統影響最大的特殊設計。
1. transfer 沒有回傳值
EIP-20 原文中,transfer 與 transferFrom 的簽名是:
function transfer(address _to, uint256 _value) public returns (bool success)
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)
後面其實接著一段很重要的話:
Callers MUST handle
falsefromreturns (bool success). Callers MUST NOT assume thatfalseis never returned!
所以可知「token 在轉帳失敗時可以選擇回傳 false 而不 revert,所以處理 false 的責任在呼叫方」。
可是 USDT 的合約裡,這三個核心函式都沒有 returns (bool):
function transfer(address _to, uint _value) public whenNotPaused { ... }
function transferFrom(address _from, address _to, uint _value) public whenNotPaused { ... }
function approve(address _spender, uint _value) public onlyPayloadSize(2 * 32) { ... }
...放到 2017 年的脈絡看,當時不少 token 合約都省略了回傳值,所以應該不是漏寫,而且編譯器也會正常通過編譯。
2. approve 有歸零鎖
EIP-20 的 approve 與 transferFrom 是兩個獨立的交易,沒有任何機制保證「先授權、再轉帳」的流程不會被搶跑:你把某人的額度從 100 改成 50,對方若搶在你的交易前先花掉 100,再等你的交易生效後又花 50,總共花了 150。
其實 EIP-20 的建議是「先把額度設成 0,再設成新值」,但有明確說到「合約本身不應強制執行,以維持向後相容」。然而 USDT 偏偏強制了,approve 裡有一行:
require(!((_value != 0) && (allowed[msg.sender][_spender] != 0)));
allowance 不為 0 時,不能直接改成另一個非零值,必須先歸零再設定。EIP-20 說「合約不應強制」,USDT 偏偏強制了。任何想要「改額度一步到位」的授權流程,遇到 USDT 都會在第二次 approve 時 revert。
3. 轉帳稅
function setParams(uint newBasisPoints, uint newMaxFee) public onlyOwner {
require(newBasisPoints < 20);
require(newMaxFee < 50);
basisPointsRate = newBasisPoints;
maximumFee = newMaxFee.mul(10**decimals);
...
}
transfer 內部會依 basisPointsRate 抽一筆 fee 給 owner,超過 maximumFee 就封頂。目前兩個參數都是 0,但 owner 隨時可以打開:費率上限 19 個基點(不到 0.2%),單筆封頂不到 50 USDT。換句話說,USDT 從第一天起就是一顆隨時可以變成 fee-on-transfer 的 token(後端賺服務費不夠還想直接從合約端賺流水的概念...)。「轉出金額等於入帳金額」這個假設,建立在 Tether 沒有動這兩個參數的前提上。
4. 黑名單、暫停、整顆換掉
addBlackList 讓 owner 凍結任何地址、destroyBlackFunds 直接銷毀黑名單地址的餘額、pause 讓所有轉帳停擺、deprecate 可以把合約邏輯導向另一個地址。這些是發行商刻意保留的合規權力,不是漏洞。對結算系統的意思是:一筆昨天還能成功的付款,今天可能因為收款地址被列入黑名單而永遠失敗,而這種失敗重試幾次都沒用。
稍微看一下乖寶寶 USDC 的公開原始碼:它乖乖回傳 bool、approve 沒有歸零鎖、沒有轉帳稅,介面層面確實是模範生。可是它同樣有 blacklist(被列入的地址不能轉帳也不能收款)、同樣有 pause,而且整份合約跑在可升級的 proxy 後面,實作隨時可以換。
這很正常,因為合規穩定幣的發行商永遠保留凍結、暫停、升級的權力。雖然喪失了一部分的 web3 風味,但這是監管下的折衷選擇,所以結算系統要把它當成環境的一部分來設計,不能當成特例來處理。
回到開頭講到的問題:用標準 IERC20 介面呼叫 USDT,為什麼會 revert?
Solidity 0.4.22 之前,編譯器不檢查外部呼叫回傳資料的長度。介面說有一個 bool,實際回來 0 bytes,編譯器就從記憶體裡撈殘值來解碼,大多時候剛好非零,被當成 true。一切看起來正常了很多年。
0.4.22 開始,編譯器插入了 returndata 長度檢查:預期 32 bytes、實際 0 bytes,整筆交易 revert,連 USDT 內部已經更新的帳一起回滾。2018 年有一份統計找出至少 130 個有同樣問題的 token,USDT、OMG、BNB 都在名單上。用新版編譯器部署的合約,就這樣突然無法跟這些 token 互動。
那為什麼有些 token 會失敗得更莫名其妙?因為他們失敗時不 revert,只回傳 false。如果你為了相容 USDT 改用低階 call、又沒有檢查回傳值,就會得到一筆「上鏈成功、gas 正常消耗、錢卻完全沒動」的幽靈支付,這對支付系統來說這就是大事故了。
以上這些行為,開源社群有一份完整目錄:weird-erc20,收錄了二十多種偏離標準的模式,建議整份讀過一遍。但對結算系統來說,逐一記住每一種沒有意義,重要的是按「支付鏈會怎麼應付它」來分類。我把它們分成四類:
1. 會 revert 的:無回傳值 token 經標準介面呼叫、approve 歸零鎖、pause 期間的轉帳。這類最友善,因為失敗是明確的:交易不會上鏈,或上鏈後狀態明確為失敗。relayer 在送出時就能知道,intent 不會被錯標。
2. 不會 revert 的:回傳 false 的 token。這類最危險,因為支付鏈上每一層看到的都是「成功」,唯一的異狀是餘額沒變。任何只看交易狀態就推進 intent 的設計都會中招;要靠讀回傳值、比對餘額、比對事件才能發現。
3. 金額對不上的:轉帳稅、以及像 USDT 這樣隨時可能開稅的 token。交易成功、錢也動了,但入帳金額小於請款金額。這類要靠對帳才會發現,帳本設計必須容許「請款金額」與「實收金額」是兩個欄位。
4. 永遠失敗的:黑名單、凍結。交易失敗,但重試多少次結果都一樣。這類的重點是要能被辨識出來、從重試佇列拿掉、轉給人工處理,否則會無限重試到 relayer 的資源被吃光。
這四類會貫穿整個系列。之後在設計轉帳封裝、對帳邏輯、失敗任務處理時,會再回到這裡。
那 Solana、TON、SUI 上的穩定幣就乾淨嗎?介面確實整齊得多,但四類例外沒有消失,只是從合約介面搬進了鏈的執行模型。
Solana 上的 USDC 與 USDT 是 SPL Token。USDC 的 mint 保留 freeze authority,發行商可以凍結任何 token account,屬於「永遠失敗」類。指令失敗會讓整筆交易原子性地失敗,所以「不會 revert」這一類在 Solana 幾乎不存在;但多了一個 EVM 沒有的前置條件,收款人的 token account 可能還沒建立,付款前得先幫對方開戶並付租金。新一代的 Token-2022 更把 transfer fee、transfer hook、permanent delegate 做成官方擴充,「金額對不上」在這裡是標準功能。
TON 上的 USDT 是 jetton,TON 官方釋出的 stablecoin-contract 就是為這類中心化穩定幣寫的範本:admin 可以鎖定任何人的 jetton wallet(還細分成不能轉出、不能轉入、全鎖)、強制銷毀、強制轉帳、整顆換 code。這些是「永遠失敗」類。但 TON 真正顛覆的是分類本身:jetton 轉帳是一串非同步訊息,你的錢包發訊息給你的 jetton wallet,它再發給對方的 jetton wallet,對方可能再回一個通知。沒有回傳值、沒有同步的成功或失敗,「會不會 revert」這個問題在 TON 上要換一種問法。之後會專門討論。
SUI 上的 USDC 是 Circle 原生發行的 regulated coin。Sui 把黑名單做進了鏈本身的 deny list:被列入的地址當下就不能轉出,下個 epoch 起連收都收不到,發行商還有 global pause 可以一鍵停掉整顆幣。「永遠失敗」類在這裡由鏈來執行,而不是合約。另一個差異在餘額本身:它不是 mapping 裡的一個數字,而是你名下的一堆 Coin<USDC> 物件,「餘額不足」會變成「手上的物件要先合併」。這也之後會討論。
四條鏈放在一起看,會發現同一件事:發行商需要的管控權在哪條鏈上都存在,差別只是藏在合約裡還是藏在鏈裡。結算系統不能假設任何一條鏈上的穩定幣是「純粹的錢」(唉,這就是為什麼有一陣子流行的 buzz word 是「Programmable Money」)。
今天的程式碼只蓋 EVM 這一部分。其他三條鏈的 mock 與本地測試環境要用 Rust、FunC、Move 各寫一套,會在寫到那幾條鏈時補上。
今天在 repo 的 contracts/evm/ 用 Foundry 起了專案,測試直接用 Solidity 寫;mock 放在 src/mocks/、測試在 test/TokenZoo.t.sol。專案結構與怎麼跑,明天講本地測試環境時一起說。今天的重點是把前面讀到的特殊設計,逐一實作到測試環境裡:
| Mock | 模仿的行為 | 原型 | 對應的類別 |
|---|---|---|---|
ERC20Mock |
完全遵守 EIP-20 | 教科書行為 | 對照組 |
USDTMock |
無回傳值、approve 歸零鎖、休眠轉帳稅、黑名單、pause | TetherToken | 會 revert/金額對不上/永遠失敗 |
NoRevertERC20Mock |
失敗回傳 false、不 revert |
規格書允許的合法行為 | 不會 revert |
FeeOnTransferERC20Mock |
常駐轉帳稅 | STA、PAXG | 金額對不上 |
USDTMock 的關鍵片段(完整版在 repo):
// 注意:沒有 returns (bool),這是整件事的重點
function transfer(address to, uint256 value) public whenNotPaused {
require(!isBlackListed[msg.sender], "blacklisted"); // 原版只擋轉出方
uint256 fee = (value * basisPointsRate) / 10_000;
if (fee > maximumFee) fee = maximumFee;
_move(msg.sender, to, value - fee);
if (fee > 0) _move(msg.sender, owner, fee);
}
然後用測試把每一類釘死。挑三個代表(測試用 transfer 示範,transferFrom 同理;vm.prank、vm.expectRevert 是 Foundry 的 cheatcode,分別用來切換呼叫者與宣告「下一個呼叫應該 revert」):
// 會 revert:用標準介面呼叫無回傳值的 token
function test_usdtStyle_revertsThroughStandardInterface() public {
vm.expectRevert();
IERC20(address(usdt)).transfer(alice, 100e6);
}
// 不會 revert:餘額不足,token 回傳 false,呼叫端若不看回傳值就是幽靈支付
function test_noRevertToken_silentFailureWhenReturnIgnored() public {
(bool success, ) = address(badToken).call(
abi.encodeCall(IERC20.transfer, (alice, 1_000_000e18))
);
assertTrue(success); // 呼叫「成功」
assertEq(badToken.balanceOf(alice), 0); // 但錢沒動
}
// 金額對不上:開稅後任意金額轉帳,三方餘額總和不變,但入帳 < 請款
function testFuzz_usdtStyle_amountIsConserved(uint256 amount) public {
amount = bound(amount, 0, usdt.balanceOf(bob));
vm.prank(owner);
usdt.setParams(10, 20);
uint256 before = usdt.balanceOf(bob) + usdt.balanceOf(alice) + usdt.balanceOf(owner);
vm.prank(bob);
usdt.transfer(alice, amount);
uint256 after_ = usdt.balanceOf(bob) + usdt.balanceOf(alice) + usdt.balanceOf(owner);
assertEq(before, after_);
}
其中第二個測試的 success 是 true、沒有 revert、gas 照燒,唯一的問題是餘額沒變。如果結算系統只看「交易有沒有成功上鏈」就把 intent 推進到 settled,這筆錢就在帳本上憑空出現了。之後設計對帳時,最終認的會是餘額與事件,交易狀態只是參考。
ERC-20 名義上是標準,實際上是一份大家各自表述的君子協定:市值最大的穩定幣公然偏離規格,偏離的方式還寫在一份 2017 年部署、再也沒改過的合約裡。改不了,只能適應。而換到另外三條鏈,例外不會消失,只是從合約介面搬進了鏈的執行模型。
今天把 EVM 的測試基礎建設搭起來、把四類例外用 mock 來完整代替。明天繼續把本地測試環境的下半部蓋完。
明天見。